Repository navigation
fix: The MCP server's upstream call has no deadline and no error handling - #525
Conversation
|
@Wittig18 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
Merged. The title understates it — this fixes two distinct failure modes, and the worse one is the hang.
The second one: Distinguishing timeouts from other failures matters more than it looks. Without it, an upstream that times out reports whatever generic message happens to surface, and the operator chases a JSON problem that doesn't exist. And naming the upstream host in every message is the difference between a debuggable failure and a bare Two merge resolutionsBoth against work that landed earlier today, neither your fault:
expect(mockFetch).toHaveBeenCalledWith('http://example.com/paid', { method: 'POST' })Your new Verified on the merge result rather than your branch: |
…ctually use it
`handlePaidCallError` matched on message text alone — `msg.includes('trustline')`,
`msg.includes('fetch')` and so on. That misclassifies as soon as a provider
rewords an error, and it already did: a payment failure whose message contained
the word "fetch" was reported as the service being unreachable.
`classifyPaidCallError` reads structured signals first — Horizon `result_codes`,
explicit error codes — and falls back to text only when there is nothing
structured, recording which path answered. A structured match is a fact; a text
match is a guess a provider can invalidate.
Completed while merging:
- **The classifier was never called.** `grep -rn classifyPaidCallError src/`
found it only in the file that defines it; `server.ts` imported just
`MCP_ERROR_CODES`. So the function shipped as dead code and the behaviour the
issue describes was unchanged. `handlePaidCallError` now delegates to it,
keeping the timeout case first and ahead of it — an AbortError carries no
structured signal and no useful message, so only the caller knows what it
means.
- **Added `isFallback` to the return**, which the PR's own tests assert on and
the implementation did not provide. Useful in its own right: a caller logging
a classification wants to know how much to trust it.
- **The branch dropped 19 tests that exist on `main`** — the Zod argument suite
from #555 and the deadline/content-type suite from #525, both landed after it
branched. Its `tests/mcp.test.ts` replaced `main`'s wholesale, so git saw no
conflict. Kept `main`'s file and appended this PR's two blocks: 37 tests.
- Fixed four `vi.fn().mockRejected(...)` / `.mockResolved(...)` calls — the
vitest APIs are `mockRejectedValue` / `mockResolvedValue`, so those tests
threw before any assertion ran.
- Restored the `captureHandler` helper the new tests need, and gave the four
server constructions a `paidCallAllowlist` for the host they call. Without it
they got `ERR_HOST_NOT_ALLOWED` — the allow-list correctly refusing before any
payment, which is worth knowing works.
Verified: `tsc --noEmit` silent, `tests/mcp.test.ts` 37 passing, and
`tests/openapi.test.ts` and `src/__tests__/pairIssuerMatch.test.ts` pass on
their own. `tests/aggregator.property.test.ts` is a pre-existing fast-check
flake that passes in isolation and on `main`.
Closes #406
Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
The per-call cap bounded how much one mistake could cost; it did not bound a day. A caller in a retry loop could pay it again and again without limit. There is now a cumulative ceiling, and the design decisions behind it are the right ones for money: - **It survives a restart.** The running total is persisted and reloaded, so restarting the server does not refill the budget. An in-memory counter would have made the ceiling advisory on any host that recycles processes. - **It fails closed.** An unreadable or corrupt state file is treated as fully spent, not as a clean slate, and the server says to fix the file and restart. That is the opposite of the right default for a rate limiter and exactly right for a spend cap. - **It writes atomically** — temp file then rename — so a crash mid-write cannot leave a truncated total that reads as a smaller spend. - **Money is exact integer nanodollars**, parsed from strings, with more than nine decimal places rejected rather than rounded. - **A rejected payment is not charged**, and a call whose worst case no longer fits is refused rather than allowed to overspend the remainder. Six resolutions while merging. The branch was 47 commits behind and both sides had restructured the same class, so git auto-merged several regions into something that looked clean and did not compile: - Two constructors were spliced together — this PR's `constructor(config)` ended up inside `main`'s field declarations. Rebuilt as one class with all fields. - An inserted `budget?: SpendBudget` field swallowed the opening `/**` of the next doc comment. - The `paid_call` description was already broken **on the PR branch**: a `),` after the budget ternary terminated the string and left the rest as a bare expression in an object literal. Concatenated properly. - Kept `main`'s `AbortSignal.timeout` on the paid fetch (#525), which this branch predates, and `classifyPaidCallError` (#480) alongside the new `SpendBudget` import. - The `payment_budget` tool is now pushed conditionally in `main`'s tools-array shape rather than spread into a literal that no longer exists. - `tests/mcp.test.ts` would have dropped 19 tests that landed on `main` after this branched. Kept `main`'s file and appended the budget suite: 46 tests. Its harness also needed `paidCallAllowlist` — the allow-list (#404) is checked before the budget, so every case was failing with ERR_HOST_NOT_ALLOWED and never reaching the ceiling — plus the `ToolSchema` import. Verified on the merge result: `tsc --noEmit` silent, 85 suites pass, nothing red. Closes #405 Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Twice in one day a branch removed test cases that existed on `main`, and git reported no conflict either time. PR #480 dropped 19 — the Zod argument suite from #555 and the upstream deadline suite from #525. PR #492 would have dropped the same 19. Both branches were opened before those landed, so merging `main` in replaced `tests/mcp.test.ts` wholesale rather than merging it: the result is the older file plus the author's additions, and everything added in between is gone. Both PRs reported "mergeable". Both typechecked. The only way I noticed was counting `it(` on each side, by hand, twice. `scripts/check-dropped-tests.mjs` compares the base against the tree a merge would actually produce — not against the branch tip, which over-reports wildly (a branch 47 commits behind is "missing" every case added since it forked, merged or not). It lists the cases by file and name and exits 1. It does not run the tests and does not judge whether a removal is right. Removing a test is sometimes exactly correct — the feature went away, the case was a duplicate, the assertion was wrong. The point is that the removal is deliberate rather than accidental: say so in the PR and apply `tests-removed-intentionally`. Validated both ways before committing, since a guard nobody has seen fire is an assumption: a branch with a case deleted exits 1 and names it; an unchanged branch exits 0. Known limitation, written down rather than left to be discovered: when the merge conflicts, `git merge-tree` writes a tree containing both sides, so no case looks missing and the check passes. That is the case a human already has to resolve by hand — which is exactly where #492's loss would have happened. The check is strongest for the silent case, which is the one that bit. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Summary
bazaar_searchin src/mcp/server.ts now has a 10s deadline, checks the response status and content type before parsing, and returns structured errors that name the upstream. The deadline isAbortSignal.timeout(10_000)passed to the fetch, as in src/alerts/delivery.ts. A non-2xx response, a response whose content-type isn't JSON, or a body that fails to parse each return anERR_SERVICE_UNREACHABLEtool error with isError set. A timeout returns the same code with a message saying the upstream did not respond within 10000ms. Every message includes the upstream host. I also gavepaid_callthe same deadline and a timeout branch inhandlePaidCallError.paid_callstill returns non-JSON bodies as text, so it doesn't reject them. I haven't run any tests, since I can't run commands here.Changed files
src/mcp/server.tstests/mcp.test.tsTest plan
Run
npx vitest run tests/mcp.test.ts. The new describe block 'bazaar_search upstream failures (#349)' covers the deadline signal being passed, a timeout, a 502 HTML response, a text/html 200, and a JSON content-type with an unparseable body. I changed the existingokFetchImplfixture to sendcontent-type: application/json, because without it the new content-type check would reject the existing success test. Then run the fullnpm test;Load test (prices)already fails on main.This PR was created as a draft by the issue solver bot. It will remain a
draft until repository CI passes.
Closes #349